总结了最近的一些面试题和之前的知识点
Axios 如何取消重复请求?
在 Web 项目开发过程中,我们经常会遇到重复请求的场景,如果系统不对重复的请求进行处理,则可能会导致系统出现各种问题。比如重复的 post 请求可能会导致服务端产生两笔记录。那么重复请求是如何产生的呢?这里我们举 2 个常见的场景:
假设页面中有一个按钮,用户点击按钮后会发起一个 AJAX 请求。如果未对该按钮进行控制,当用户快速点击按钮时,则会发出重复请求。
假设在考试结果查询页面中,用户可以根据 “已通过”、“未通过” 和 “全部” 3 种查询条件来查询考试结果。如果请求的响应比较慢,当用户在不同的查询条件之前快速切换时,就会产生重复请求。
一、如何取消请求
Axios 是一个基于 Promise 的 HTTP 客户端,同时支持浏览器和 Node.js 环境。它是一个优秀的 HTTP 客户端,被广泛地应用在大量的 Web 项目中。对于浏览器环境来说,Axios 底层是利用 XMLHttpRequest 对象来发起 HTTP 请求。如果要取消请求的话,我们可以通过调用 XMLHttpRequest 对象上的 abort 方法来取消请求:
1 2 3 4
| let xhr = new XMLHttpRequest(); xhr.open("GET", "https://developer.mozilla.org/", true); xhr.send(); setTimeout(() => xhr.abort(), 300);
|
而对于 Axios 来说,我们可以通过 Axios 内部提供的 CancelToken 来取消请求:
1 2 3 4 5 6 7 8 9 10
| const CancelToken = axios.CancelToken; const source = CancelToken.source();
axios.post('/user/12345', { name: 'semlinker' }, { cancelToken: source.token })
source.cancel('Operation canceled by the user.');
|
此外,你也可以通过调用 CancelToken 的构造函数来创建 CancelToken,具体如下所示:
1 2 3 4 5 6 7 8 9 10
| const CancelToken = axios.CancelToken; let cancel;
axios.get('/user/12345', { cancelToken: new CancelToken(function executor(c) { cancel = c; }) });
cancel();
|
现在我们已经知道在 Axios 中如何使用 CancelToken 来取消请求了,那么 CancelToken 内部是如何工作的呢?这里我们先记住这个问题,后面揭开 CancelToken 背后的秘密。接下来,我们来分析一下如何判断重复请求。
二、如何判断重复请求
当请求方式、请求 URL 地址和请求参数都一样时,我们就可以认为请求是一样的。因此在每次发起请求时,我们就可以根据当前请求的请求方式、请求 URL 地址和请求参数来生成一个唯一的 key,同时为每个请求创建一个专属的 CancelToken,然后把 key 和 cancel 函数以键值对的形式保存到 Map 对象中,使用 Map 的好处是可以快速的判断是否有重复的请求:
1 2 3 4 5 6 7 8 9 10
| import qs from 'qs'
const pendingRequest = new Map();
const requestKey = [method, url, qs.stringify(params), qs.stringify(data)].join('&'); const cancelToken = new CancelToken(function executor(cancel)
})
|
当出现重复请求的时候,我们就可以使用 cancel 函数来取消前面已经发出的请求,在取消请求之后,我们还需要把取消的请求从 pendingRequest 中移除。现在我们已经知道如何取消请求和如何判断重复请求,下面我们来介绍如何取消重复请求。
三、如何取消重复请求
因为我们需要对所有的请求都进行处理,所以我们可以考虑使用 Axios 的拦截器机制来实现取消重复请求的功能。Axios 为开发者提供了请求拦截器和响应拦截器,它们的作用如下:
- 请求拦截器:该类拦截器的作用是在请求发送前统一执行某些操作,比如在请求头中添加 token 字段。
- 响应拦截器:该类拦截器的作用是在接收到服务器响应后统一执行某些操作,比如发现响应状态码为 401 时,自动跳转到登录页。
四、CancelToken 的工作原理
在前面的示例中,我们是通过调用 CancelToken 构造函数来创建 CancelToken 对象:
1 2 3 4 5
| new axios.CancelToken((cancel) => { if (!pendingRequest.has(requestKey)) { pendingRequest.set(requestKey, cancel); } })
|
所以接下来,我们来分析 CancelToken 构造函数,该函数被定义在 lib/cancel/CancelToken.js 文件中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function CancelToken(executor) { if (typeof executor !== 'function') { throw new TypeError('executor must be a function.'); }
var resolvePromise; this.promise = new Promise(function promiseExecutor(resolve) { resolvePromise = resolve; });
var token = this; executor(function cancel(message) { if (token.reason) { return; } token.reason = new Cancel(message); resolvePromise(token.reason); }); }
|
由以上代码可知,cancel 对象是一个函数,当我们调用该函数后,会创建 Cancel 对象并调用 resolvePromise 方法。该方法执行后,CancelToken 对象上 promise 属性所指向的 promise 对象的状态将变为 resolved。那么这样做的目的是什么呢?这里我们从 lib/adapters/xhr.js 文件中找到了答案:
1 2 3 4 5 6 7 8 9
| // lib/adapters/xhr.js if (config.cancelToken) { config.cancelToken.promise.then(function onCanceled(cancel) { if (!request) { return; } request.abort(); // 取消请求 reject(cancel); request = null; }); }
|
本文介绍了在 Axios 中如何取消重复请求及 CancelToken 的工作原理,需要注意的是已取消的请求可能已经达到服务端,针对这种情形,服务端的对应接口需要进行幂等控制
参考文章
Axios 如何取消重复请求?
前端上传大文件怎么处理
背景
当我们在做文件的导入功能的时候,如果导入的文件过大,可能会导所需要的时间够长,且失败后需要重新上传,我们需要前后端结合的方式解决这个问题
思路
我们需要做几件事情如下:
- 对文件做切片,即将一个请求拆分成多个请求,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始
- 通知服务器合并切片,在上传完切片后,前端通知服务器做合并切片操作
- 控制多个请求的并发量,防止多个请求同时发送,造成浏览器内存溢出,导致页面卡死
- 做断点续传,当多个请求中有请求发送失败,例如出现网络故障、页面关闭等,我们得对失败的请求做处理,让它们重复发送
步骤1-切片,合并切片
在JavaScript中,文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice通过这个方法,我们就可以对二进制文件进行拆分,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=s, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn">上传</button> </body> <script>
axios.defaults.baseURL = 'http://localhost:3000'
var file = null
document.getElementById('fileInput').onchange = function({target: {files}}){ file = files[0] }
document.getElementById('uploadBtn').onclick = async function(){ if (!file) return let size = 1024 *50 let fileChunks = [] let index = 0 for(let cur = 0; cur < file.size; cur += size){ fileChunks.push({ hash: index++, chunk: file.slice(cur, cur + size) }) } const uploadList = fileChunks.map((item, index) => { let formData = new FormData() formData.append('filename', file.name) formData.append('hash', item.hash) formData.append('chunk', item.chunk) return axios({ method: 'post', url: '/upload', data: formData }) }) await Promise.all(uploadList) await axios({ method: 'get', url: '/merge', params: { filename: file.name } }); console.log('上传完成') } </script> </html>
|
步骤2-并发控制
结合Promise.race和异步函数实现,多个请求同时并发的数量,防止浏览器内存溢出,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=s, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn">上传</button> </body> <script>
axios.defaults.baseURL = 'http://localhost:3000'
var file = null
document.getElementById('fileInput').onchange = function({target: {files}}){ file = files[0] }
document.getElementById('uploadBtn').onclick = async function(){ if (!file) return
let size = 1024*50 let fileChunks = [] let index = 0 for(let cur = 0; cur < file.size; cur += size){ fileChunks.push({ hash: index++, chunk: file.slice(cur, cur + size) }); } let pool = [] let max = 3 for(let i=0;i<fileChunks.length;i++){ let item = fileChunks[i] let formData = new FormData() formData.append('filename', file.name) formData.append('hash', item.hash) formData.append('chunk', item.chunk) let task = axios({ method: 'post', url: '/upload', data: formData }) task.then((data)=>{ let index = pool.findIndex(t=> t===task) pool.splice(index) }) pool.push(task) if(pool.length === max){ await Promise.race(pool) } } await axios({ method: 'get', url: '/merge', params: { filename: file.name } }); console.log('上传完成') } </script> </html>
|
步骤3-断点续传
在单个请求失败后,触发catch的方法的时候,讲当前请求放到失败列表中,在本轮请求完成后,重复对失败请求做处理,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=s, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn">上传</button> </body> <script>
axios.defaults.baseURL = 'http://localhost:3000'
var file = null
document.getElementById('fileInput').onchange = function({target: {files}}){ file = files[0] }
document.getElementById('uploadBtn').onclick = function(){ if (!file) return;
let size = 1024* 50; let fileChunks = []; let index = 0 for(let cur = 0; cur < file.size; cur += size){ fileChunks.push({ hash: index++, chunk: file.slice(cur, cur + size) }) } const uploadFileChunks = async function(list){ if(list.length === 0){ await axios({ method: 'get', url: '/merge', params: { filename: file.name } }); console.log('上传完成') return } let pool = [] let max = 3 let finish = 0 let failList = [] for(let i=0;i<list.length;i++){ let item = list[i] let formData = new FormData() formData.append('filename', file.name) formData.append('hash', item.hash) formData.append('chunk', item.chunk) let task = axios({ method: 'post', url: '/upload', data: formData }) task.then((data)=>{ let index = pool.findIndex(t=> t===task) pool.splice(index) }).catch(()=>{ failList.push(item) }).finally(()=>{ finish++ if(finish===list.length){ uploadFileChunks(failList) } }) pool.push(task) if(pool.length === max){ await Promise.race(pool) } } } uploadFileChunks(fileChunks)
} </script> </html>
|
后端
步骤1.安装依赖
1 2
| npm i express@4.17.2 npm i multiparty@4.2.2
|
步骤2.接口实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| const express = require('express') const multiparty = require('multiparty') const fs = require('fs') const path = require('path') const { Buffer } = require('buffer')
const STATIC_FILES = path.join(__dirname, './static/files')
const STATIC_TEMPORARY = path.join(__dirname, './static/temporary') const server = express()
server.use(express.static(path.join(__dirname, './dist')))
server.post('/upload', (req, res) => { const form = new multiparty.Form(); form.parse(req, function(err, fields, files) { let filename = fields.filename[0] let hash = fields.hash[0] let chunk = files.chunk[0] let dir = `${STATIC_TEMPORARY}/${filename}` try { if (!fs.existsSync(dir)) fs.mkdirSync(dir) const buffer = fs.readFileSync(chunk.path) const ws = fs.createWriteStream(`${dir}/${hash}`) ws.write(buffer) ws.close() res.send(`${filename}-${hash} 切片上传成功`) } catch (error) { console.error(error) res.status(500).send(`${filename}-${hash} 切片上传失败`) } }) })
server.get('/merge', async (req, res) => { const { filename } = req.query try { let len = 0 const bufferList = fs.readdirSync(`${STATIC_TEMPORARY}/${filename}`).map((hash,index) => { const buffer = fs.readFileSync(`${STATIC_TEMPORARY}/${filename}/${index}`) len += buffer.length return buffer }); const buffer = Buffer.concat(bufferList, len); const ws = fs.createWriteStream(`${STATIC_FILES}/${filename}`) ws.write(buffer); ws.close(); res.send(`切片合并完成`); } catch (error) { console.error(error); } })
server.listen(3000, _ => { console.log('http://localhost:3000/') })
|
其他实现
如果使用腾讯云或阿里云文件上传的服务,它们提供了npm库,例如腾讯云的cos-js-sdk-v5,它自身提供的切片相关的配置
参考文章
前端上传大文件怎么处理